mcp-coordinator 0.4.0 → 0.5.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/README.md +14 -0
- package/dashboard/public/index.html +23 -0
- package/dist/cli/server/start.js +33 -0
- package/dist/src/announce-workflow.d.ts +1 -0
- package/dist/src/announce-workflow.js +28 -0
- package/dist/src/database.js +65 -0
- package/dist/src/file-tracker.d.ts +2 -0
- package/dist/src/file-tracker.js +3 -2
- package/dist/src/git-cochange-builder.d.ts +32 -0
- package/dist/src/git-cochange-builder.js +238 -0
- package/dist/src/http/handle-health.d.ts +1 -1
- package/dist/src/http/handle-health.js +26 -0
- package/dist/src/http/handle-rest.js +83 -2
- package/dist/src/http/utils.d.ts +0 -4
- package/dist/src/http/utils.js +16 -2
- package/dist/src/impact-scorer.d.ts +5 -1
- package/dist/src/impact-scorer.js +98 -8
- package/dist/src/metrics.d.ts +5 -0
- package/dist/src/metrics.js +33 -0
- package/dist/src/path-normalize.d.ts +17 -0
- package/dist/src/path-normalize.js +38 -0
- package/dist/src/serve-http.js +41 -2
- package/dist/src/server-setup.d.ts +6 -0
- package/dist/src/server-setup.js +23 -3
- package/dist/src/tools/consultation-tools.js +4 -2
- package/dist/src/tree-sitter-extractor.d.ts +36 -0
- package/dist/src/tree-sitter-extractor.js +354 -0
- package/dist/src/working-files-tracker.d.ts +42 -0
- package/dist/src/working-files-tracker.js +111 -0
- package/package.json +18 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Metrics } from "./metrics.js";
|
|
2
|
+
/**
|
|
3
|
+
* Tree-sitter symbol extractor.
|
|
4
|
+
*
|
|
5
|
+
* Loads grammars asynchronously at boot. extract() runs synchronously per call
|
|
6
|
+
* so it slots into the existing synchronous file_activity ingest path.
|
|
7
|
+
*
|
|
8
|
+
* Naming table per language documented in the v0.6 spec:
|
|
9
|
+
* - top-level fn / arrow assigned to const → `name`
|
|
10
|
+
* - class member → `Class.method`
|
|
11
|
+
* - anonymous default export → `<file_basename>:default`
|
|
12
|
+
* - re-exports, anonymous IIFE → not emitted
|
|
13
|
+
*/
|
|
14
|
+
export declare class TreeSitterExtractor {
|
|
15
|
+
private grammars;
|
|
16
|
+
private ready;
|
|
17
|
+
private grammarsLoaded;
|
|
18
|
+
private totalGrammars;
|
|
19
|
+
private metrics?;
|
|
20
|
+
constructor(metrics?: Metrics);
|
|
21
|
+
load(): Promise<void>;
|
|
22
|
+
status(): {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
grammars_loaded: number;
|
|
25
|
+
total_grammars: number;
|
|
26
|
+
optional: true;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Extract qualified symbol names from `content`. Returns null on parse
|
|
30
|
+
* failure, unsupported extension, or grammar not loaded.
|
|
31
|
+
* Caps output at 200 entries (per spec).
|
|
32
|
+
*/
|
|
33
|
+
extract(filePath: string, content: string, _changedRanges: Array<[number, number]> | null): string[] | null;
|
|
34
|
+
private extToKey;
|
|
35
|
+
private walk;
|
|
36
|
+
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Go receiver helpers (module-level so HANDLERS closure can reference them)
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
function goReceiverType(recv) {
|
|
6
|
+
if (!recv)
|
|
7
|
+
return null;
|
|
8
|
+
for (let i = 0; i < recv.namedChildCount; i++) {
|
|
9
|
+
const found = findGoTypeIdent(recv.namedChild(i));
|
|
10
|
+
if (found)
|
|
11
|
+
return found;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function findGoTypeIdent(node) {
|
|
16
|
+
if (!node)
|
|
17
|
+
return null;
|
|
18
|
+
if (node.type === "type_identifier")
|
|
19
|
+
return node.text;
|
|
20
|
+
if (node.type === "pointer_type") {
|
|
21
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
22
|
+
const found = findGoTypeIdent(node.namedChild(i));
|
|
23
|
+
if (found)
|
|
24
|
+
return found;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (node.type === "parameter_declaration") {
|
|
28
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
29
|
+
const child = node.namedChild(i);
|
|
30
|
+
if (child.type !== "identifier") {
|
|
31
|
+
const found = findGoTypeIdent(child);
|
|
32
|
+
if (found)
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// HANDLERS registry — one entry per language key
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
const HANDLERS = {
|
|
43
|
+
ts: {
|
|
44
|
+
classNodeTypes: new Set(["class_declaration"]),
|
|
45
|
+
fnNodeTypes: new Set(["function_declaration", "method_definition"]),
|
|
46
|
+
varDeclTypes: new Set(["variable_declarator"]),
|
|
47
|
+
exportStmtTypes: new Set(["export_statement"]),
|
|
48
|
+
},
|
|
49
|
+
tsx: {
|
|
50
|
+
classNodeTypes: new Set(["class_declaration"]),
|
|
51
|
+
fnNodeTypes: new Set(["function_declaration", "method_definition"]),
|
|
52
|
+
varDeclTypes: new Set(["variable_declarator"]),
|
|
53
|
+
exportStmtTypes: new Set(["export_statement"]),
|
|
54
|
+
},
|
|
55
|
+
js: {
|
|
56
|
+
classNodeTypes: new Set(["class_declaration"]),
|
|
57
|
+
fnNodeTypes: new Set(["function_declaration", "method_definition"]),
|
|
58
|
+
varDeclTypes: new Set(["variable_declarator"]),
|
|
59
|
+
exportStmtTypes: new Set(["export_statement"]),
|
|
60
|
+
},
|
|
61
|
+
py: {
|
|
62
|
+
classNodeTypes: new Set(["class_definition"]),
|
|
63
|
+
fnNodeTypes: new Set(["function_definition"]),
|
|
64
|
+
},
|
|
65
|
+
go: {
|
|
66
|
+
classNodeTypes: new Set(),
|
|
67
|
+
fnNodeTypes: new Set(["function_declaration", "method_declaration"]),
|
|
68
|
+
extractFnName: (node, rawName, classCtx, _ctx) => {
|
|
69
|
+
if (node.type === "method_declaration") {
|
|
70
|
+
const recv = node.childForFieldName?.("receiver");
|
|
71
|
+
const recvType = goReceiverType(recv);
|
|
72
|
+
if (recvType && rawName)
|
|
73
|
+
return `${recvType}.${rawName}`;
|
|
74
|
+
return rawName;
|
|
75
|
+
}
|
|
76
|
+
if (classCtx && rawName)
|
|
77
|
+
return `${classCtx}.${rawName}`;
|
|
78
|
+
return rawName;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
rust: {
|
|
82
|
+
classNodeTypes: new Set(),
|
|
83
|
+
fnNodeTypes: new Set(["function_item"]),
|
|
84
|
+
containerNodeTypes: new Set(["impl_item"]),
|
|
85
|
+
extractContainerName: (node) => node.childForFieldName?.("type")?.text ?? null,
|
|
86
|
+
},
|
|
87
|
+
java: {
|
|
88
|
+
classNodeTypes: new Set(["class_declaration"]),
|
|
89
|
+
fnNodeTypes: new Set(["method_declaration"]),
|
|
90
|
+
},
|
|
91
|
+
cs: {
|
|
92
|
+
classNodeTypes: new Set(["class_declaration", "interface_declaration", "struct_declaration", "record_declaration"]),
|
|
93
|
+
fnNodeTypes: new Set(["method_declaration", "constructor_declaration", "property_declaration"]),
|
|
94
|
+
containerNodeTypes: new Set(["namespace_declaration"]),
|
|
95
|
+
extractContainerName: (_node) => null,
|
|
96
|
+
},
|
|
97
|
+
c: {
|
|
98
|
+
// C: function name lives in declarator field, not "name" field
|
|
99
|
+
classNodeTypes: new Set(),
|
|
100
|
+
fnNodeTypes: new Set(["function_definition"]),
|
|
101
|
+
extractFnName: (node, _rawName, classCtx, _ctx) => {
|
|
102
|
+
// function_definition -> declarator (function_declarator) -> declarator (identifier)
|
|
103
|
+
const decl = node.childForFieldName?.("declarator");
|
|
104
|
+
const name = decl?.childForFieldName?.("declarator")?.text ?? decl?.namedChild?.(0)?.text ?? null;
|
|
105
|
+
if (name && classCtx)
|
|
106
|
+
return `${classCtx}.${name}`;
|
|
107
|
+
return name;
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
cpp: {
|
|
111
|
+
// C++: class_specifier has "name" field; function name via declarator chain
|
|
112
|
+
classNodeTypes: new Set(["class_specifier", "struct_specifier"]),
|
|
113
|
+
fnNodeTypes: new Set(["function_definition"]),
|
|
114
|
+
extractFnName: (node, _rawName, classCtx, _ctx) => {
|
|
115
|
+
const decl = node.childForFieldName?.("declarator");
|
|
116
|
+
const name = decl?.childForFieldName?.("declarator")?.text ?? decl?.namedChild?.(0)?.text ?? null;
|
|
117
|
+
if (name && classCtx)
|
|
118
|
+
return `${classCtx}.${name}`;
|
|
119
|
+
return name;
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
ruby: {
|
|
123
|
+
classNodeTypes: new Set(["class"]),
|
|
124
|
+
fnNodeTypes: new Set(["method", "singleton_method"]),
|
|
125
|
+
containerNodeTypes: new Set(["module"]),
|
|
126
|
+
extractContainerName: (node) => node.childForFieldName?.("name")?.text ?? null,
|
|
127
|
+
},
|
|
128
|
+
php: {
|
|
129
|
+
classNodeTypes: new Set(["class_declaration", "interface_declaration", "trait_declaration"]),
|
|
130
|
+
fnNodeTypes: new Set(["method_declaration", "function_definition"]),
|
|
131
|
+
containerNodeTypes: new Set(["namespace_definition"]),
|
|
132
|
+
extractContainerName: (_node) => null,
|
|
133
|
+
},
|
|
134
|
+
kotlin: {
|
|
135
|
+
// Kotlin: class/object have no "name" field — use namedChild(0) (type_identifier)
|
|
136
|
+
// function_declaration has no "name" field — use namedChild(0) (simple_identifier)
|
|
137
|
+
classNodeTypes: new Set(),
|
|
138
|
+
fnNodeTypes: new Set(["function_declaration"]),
|
|
139
|
+
containerNodeTypes: new Set(["class_declaration", "object_declaration"]),
|
|
140
|
+
extractContainerName: (node) => node.namedChild(0)?.text ?? null,
|
|
141
|
+
extractFnName: (node, _rawName, classCtx, _ctx) => {
|
|
142
|
+
const name = node.namedChild(0)?.text ?? null;
|
|
143
|
+
if (name && classCtx)
|
|
144
|
+
return `${classCtx}.${name}`;
|
|
145
|
+
return name;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
swift: {
|
|
149
|
+
classNodeTypes: new Set(["class_declaration", "struct_declaration", "protocol_declaration"]),
|
|
150
|
+
fnNodeTypes: new Set(["function_declaration"]),
|
|
151
|
+
containerNodeTypes: new Set(["extension_declaration"]),
|
|
152
|
+
extractContainerName: (node) => node.childForFieldName?.("type")?.text
|
|
153
|
+
?? node.namedChild(0)?.text
|
|
154
|
+
?? null,
|
|
155
|
+
},
|
|
156
|
+
bash: {
|
|
157
|
+
classNodeTypes: new Set(),
|
|
158
|
+
fnNodeTypes: new Set(["function_definition"]),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Tree-sitter symbol extractor.
|
|
163
|
+
*
|
|
164
|
+
* Loads grammars asynchronously at boot. extract() runs synchronously per call
|
|
165
|
+
* so it slots into the existing synchronous file_activity ingest path.
|
|
166
|
+
*
|
|
167
|
+
* Naming table per language documented in the v0.6 spec:
|
|
168
|
+
* - top-level fn / arrow assigned to const → `name`
|
|
169
|
+
* - class member → `Class.method`
|
|
170
|
+
* - anonymous default export → `<file_basename>:default`
|
|
171
|
+
* - re-exports, anonymous IIFE → not emitted
|
|
172
|
+
*/
|
|
173
|
+
export class TreeSitterExtractor {
|
|
174
|
+
grammars = new Map();
|
|
175
|
+
ready = false;
|
|
176
|
+
grammarsLoaded = 0;
|
|
177
|
+
totalGrammars = 15;
|
|
178
|
+
metrics;
|
|
179
|
+
constructor(metrics) {
|
|
180
|
+
this.metrics = metrics;
|
|
181
|
+
}
|
|
182
|
+
async load() {
|
|
183
|
+
const tryLoad = async (key, modName, sub) => {
|
|
184
|
+
try {
|
|
185
|
+
const tsMod = await import("tree-sitter").catch(() => null);
|
|
186
|
+
const langMod = await import(modName).catch(() => null);
|
|
187
|
+
if (!tsMod || !langMod)
|
|
188
|
+
return;
|
|
189
|
+
const Parser = tsMod.default || tsMod;
|
|
190
|
+
const parser = new Parser();
|
|
191
|
+
const langObj = langMod.default || langMod;
|
|
192
|
+
const language = sub ? langObj[sub] : langObj;
|
|
193
|
+
if (!language)
|
|
194
|
+
return;
|
|
195
|
+
parser.setLanguage(language);
|
|
196
|
+
this.grammars.set(key, { parser, language });
|
|
197
|
+
this.grammarsLoaded++;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// optionalDependency — silently skip when unavailable
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
await tryLoad("ts", "tree-sitter-typescript", "typescript");
|
|
204
|
+
await tryLoad("tsx", "tree-sitter-typescript", "tsx");
|
|
205
|
+
await tryLoad("js", "tree-sitter-javascript");
|
|
206
|
+
await tryLoad("py", "tree-sitter-python");
|
|
207
|
+
await tryLoad("go", "tree-sitter-go");
|
|
208
|
+
await tryLoad("rust", "tree-sitter-rust");
|
|
209
|
+
await tryLoad("java", "tree-sitter-java");
|
|
210
|
+
await tryLoad("cs", "tree-sitter-c-sharp");
|
|
211
|
+
await tryLoad("c", "tree-sitter-c");
|
|
212
|
+
await tryLoad("cpp", "tree-sitter-cpp");
|
|
213
|
+
await tryLoad("ruby", "tree-sitter-ruby");
|
|
214
|
+
await tryLoad("php", "tree-sitter-php", "php");
|
|
215
|
+
await tryLoad("kotlin", "tree-sitter-kotlin");
|
|
216
|
+
await tryLoad("swift", "tree-sitter-swift");
|
|
217
|
+
await tryLoad("bash", "tree-sitter-bash");
|
|
218
|
+
this.ready = true;
|
|
219
|
+
}
|
|
220
|
+
status() {
|
|
221
|
+
return {
|
|
222
|
+
ok: this.ready && this.grammarsLoaded > 0,
|
|
223
|
+
grammars_loaded: this.grammarsLoaded,
|
|
224
|
+
total_grammars: this.totalGrammars,
|
|
225
|
+
optional: true,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Extract qualified symbol names from `content`. Returns null on parse
|
|
230
|
+
* failure, unsupported extension, or grammar not loaded.
|
|
231
|
+
* Caps output at 200 entries (per spec).
|
|
232
|
+
*/
|
|
233
|
+
extract(filePath, content, _changedRanges) {
|
|
234
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
235
|
+
const key = this.extToKey(ext);
|
|
236
|
+
if (!key)
|
|
237
|
+
return null;
|
|
238
|
+
const grammar = this.grammars.get(key);
|
|
239
|
+
if (!grammar)
|
|
240
|
+
return null;
|
|
241
|
+
let tree;
|
|
242
|
+
try {
|
|
243
|
+
tree = grammar.parser.parse(content);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
this.metrics?.treeSitterParseFailures.inc();
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (!tree || !tree.rootNode || tree.rootNode.hasError) {
|
|
250
|
+
this.metrics?.treeSitterParseFailures.inc();
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const symbols = [];
|
|
254
|
+
this.walk(tree.rootNode, symbols, key, path.basename(filePath, ext));
|
|
255
|
+
return symbols.slice(0, 200);
|
|
256
|
+
}
|
|
257
|
+
extToKey(ext) {
|
|
258
|
+
switch (ext) {
|
|
259
|
+
case ".ts": return "ts";
|
|
260
|
+
case ".tsx": return "tsx";
|
|
261
|
+
case ".js":
|
|
262
|
+
case ".jsx":
|
|
263
|
+
case ".mjs":
|
|
264
|
+
case ".cjs": return "js";
|
|
265
|
+
case ".py": return "py";
|
|
266
|
+
case ".go": return "go";
|
|
267
|
+
case ".rs": return "rust";
|
|
268
|
+
case ".java": return "java";
|
|
269
|
+
case ".cs": return "cs";
|
|
270
|
+
case ".c":
|
|
271
|
+
case ".h": return "c";
|
|
272
|
+
case ".cpp":
|
|
273
|
+
case ".cc":
|
|
274
|
+
case ".cxx":
|
|
275
|
+
case ".hpp":
|
|
276
|
+
case ".hh": return "cpp";
|
|
277
|
+
case ".rb": return "ruby";
|
|
278
|
+
case ".php": return "php";
|
|
279
|
+
case ".kt":
|
|
280
|
+
case ".kts": return "kotlin";
|
|
281
|
+
case ".swift": return "swift";
|
|
282
|
+
case ".sh":
|
|
283
|
+
case ".bash": return "bash";
|
|
284
|
+
default: return null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
walk(node, out, lang, basename, classCtx = null) {
|
|
288
|
+
if (out.length >= 200)
|
|
289
|
+
return;
|
|
290
|
+
const handler = HANDLERS[lang];
|
|
291
|
+
if (!handler)
|
|
292
|
+
return;
|
|
293
|
+
const type = node.type;
|
|
294
|
+
const nameField = handler.classNameField ?? "name";
|
|
295
|
+
const nameNode = node.childForFieldName?.(nameField);
|
|
296
|
+
const ctx = { out, lang, basename };
|
|
297
|
+
// 1. Class-like containers
|
|
298
|
+
if (handler.classNodeTypes.has(type)) {
|
|
299
|
+
const className = nameNode?.text ?? null;
|
|
300
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
301
|
+
this.walk(node.namedChild(i), out, lang, basename, className);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// 2. Non-class containers (Rust impl, Ruby module, C# namespace, etc.)
|
|
306
|
+
if (handler.containerNodeTypes?.has(type)) {
|
|
307
|
+
const containerName = handler.extractContainerName?.(node) ?? null;
|
|
308
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
309
|
+
this.walk(node.namedChild(i), out, lang, basename, containerName);
|
|
310
|
+
}
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// 3. Function-like leaves
|
|
314
|
+
if (handler.fnNodeTypes.has(type)) {
|
|
315
|
+
const rawName = node.childForFieldName?.("name")?.text ?? null;
|
|
316
|
+
let emitted;
|
|
317
|
+
if (handler.extractFnName) {
|
|
318
|
+
emitted = handler.extractFnName(node, rawName, classCtx, ctx);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
emitted = rawName;
|
|
322
|
+
if (emitted && classCtx)
|
|
323
|
+
emitted = `${classCtx}.${emitted}`;
|
|
324
|
+
}
|
|
325
|
+
if (emitted)
|
|
326
|
+
out.push(emitted);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
// 4. Variable declarators (const X = () => …)
|
|
330
|
+
if (handler.varDeclTypes?.has(type)) {
|
|
331
|
+
const valNode = node.childForFieldName?.("value");
|
|
332
|
+
if (valNode && (valNode.type === "arrow_function" || valNode.type === "function_expression")) {
|
|
333
|
+
const name = node.childForFieldName?.("name")?.text;
|
|
334
|
+
if (name)
|
|
335
|
+
out.push(name);
|
|
336
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
// 5. Anonymous default exports
|
|
340
|
+
if (handler.exportStmtTypes?.has(type)) {
|
|
341
|
+
const decl = node.namedChild(0);
|
|
342
|
+
if (decl && (decl.type === "arrow_function" ||
|
|
343
|
+
(decl.type === "function_declaration" && !decl.childForFieldName?.("name")))) {
|
|
344
|
+
out.push(`${basename}:default`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// Named export — fall through to recurse so class/fn inside are captured
|
|
348
|
+
}
|
|
349
|
+
// Recurse
|
|
350
|
+
for (let i = 0; i < node.namedChildCount; i++) {
|
|
351
|
+
this.walk(node.namedChild(i), out, lang, basename, classCtx);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type Logger } from "./logger.js";
|
|
2
|
+
import type { Metrics } from "./metrics.js";
|
|
3
|
+
/**
|
|
4
|
+
* Tracks files an agent is currently editing (between PreToolUse and
|
|
5
|
+
* PostToolUse hooks). Distinct from file_activity — that's an append-only
|
|
6
|
+
* historical log; this is current state with TTL.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* PreToolUse → start(agent, file, ttlMin) → UPSERT row
|
|
10
|
+
* PostToolUse → stop(agent, file) → DELETE row
|
|
11
|
+
* Sweeper → sweepExpired() → DELETE rows past claim_until
|
|
12
|
+
* Agent LWT → clearForAgent(agent) → DELETE all rows for agent
|
|
13
|
+
*/
|
|
14
|
+
export declare class WorkingFilesTracker {
|
|
15
|
+
private sweeperHandle;
|
|
16
|
+
private log;
|
|
17
|
+
private metrics?;
|
|
18
|
+
constructor(logger?: Logger, metrics?: Metrics);
|
|
19
|
+
/**
|
|
20
|
+
* Start (or refresh) a working-files claim. Idempotent: re-calling with
|
|
21
|
+
* the same (agent_id, file_path) updates last_activity_at + claim_until
|
|
22
|
+
* without erroring.
|
|
23
|
+
*/
|
|
24
|
+
start(agentId: string, filePath: string, ttlMinutes: number): void;
|
|
25
|
+
/**
|
|
26
|
+
* Stop a working-files claim. No-op when no row matches (PostToolUse can
|
|
27
|
+
* arrive after a TTL eviction or before the matching PreToolUse on slow Pre).
|
|
28
|
+
*/
|
|
29
|
+
stop(agentId: string, filePath: string): void;
|
|
30
|
+
/** Returns number of rows evicted. */
|
|
31
|
+
sweepExpired(): number;
|
|
32
|
+
/** Called when an agent goes offline (MQTT LWT). Returns rows deleted. */
|
|
33
|
+
clearForAgent(agentId: string): number;
|
|
34
|
+
/**
|
|
35
|
+
* Background sweeper. unref() so it doesn't keep the loop alive at shutdown.
|
|
36
|
+
* Idempotent — second call is a no-op until stopSweeper().
|
|
37
|
+
*/
|
|
38
|
+
startSweeper(intervalMs?: number): void;
|
|
39
|
+
stopSweeper(): void;
|
|
40
|
+
/** Read in-flight files map: file_path → set<agent_id>, excluding caller. */
|
|
41
|
+
getIndex(filePaths: string[], excludeAgentId: string): Map<string, Set<string>>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getDb } from "./database.js";
|
|
2
|
+
import { silentLogger } from "./logger.js";
|
|
3
|
+
/**
|
|
4
|
+
* Tracks files an agent is currently editing (between PreToolUse and
|
|
5
|
+
* PostToolUse hooks). Distinct from file_activity — that's an append-only
|
|
6
|
+
* historical log; this is current state with TTL.
|
|
7
|
+
*
|
|
8
|
+
* Lifecycle:
|
|
9
|
+
* PreToolUse → start(agent, file, ttlMin) → UPSERT row
|
|
10
|
+
* PostToolUse → stop(agent, file) → DELETE row
|
|
11
|
+
* Sweeper → sweepExpired() → DELETE rows past claim_until
|
|
12
|
+
* Agent LWT → clearForAgent(agent) → DELETE all rows for agent
|
|
13
|
+
*/
|
|
14
|
+
export class WorkingFilesTracker {
|
|
15
|
+
sweeperHandle = null;
|
|
16
|
+
log;
|
|
17
|
+
metrics;
|
|
18
|
+
constructor(logger, metrics) {
|
|
19
|
+
this.log = logger || silentLogger;
|
|
20
|
+
this.metrics = metrics;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Start (or refresh) a working-files claim. Idempotent: re-calling with
|
|
24
|
+
* the same (agent_id, file_path) updates last_activity_at + claim_until
|
|
25
|
+
* without erroring.
|
|
26
|
+
*/
|
|
27
|
+
start(agentId, filePath, ttlMinutes) {
|
|
28
|
+
const db = getDb();
|
|
29
|
+
const existing = db.prepare("SELECT 1 FROM working_files WHERE agent_id = ? AND file_path = ?")
|
|
30
|
+
.get(agentId, filePath);
|
|
31
|
+
db.prepare(`INSERT INTO working_files (agent_id, file_path, started_at, last_activity_at, claim_until)
|
|
32
|
+
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+' || CAST(? AS TEXT) || ' minutes'))
|
|
33
|
+
ON CONFLICT(agent_id, file_path) DO UPDATE SET
|
|
34
|
+
last_activity_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
|
|
35
|
+
claim_until = strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '+' || CAST(? AS TEXT) || ' minutes')`).run(agentId, filePath, ttlMinutes, ttlMinutes);
|
|
36
|
+
this.metrics?.workingFilesStarts.inc({ result: existing ? "updated" : "inserted" });
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Stop a working-files claim. No-op when no row matches (PostToolUse can
|
|
40
|
+
* arrive after a TTL eviction or before the matching PreToolUse on slow Pre).
|
|
41
|
+
*/
|
|
42
|
+
stop(agentId, filePath) {
|
|
43
|
+
const db = getDb();
|
|
44
|
+
db.prepare("DELETE FROM working_files WHERE agent_id = ? AND file_path = ?")
|
|
45
|
+
.run(agentId, filePath);
|
|
46
|
+
}
|
|
47
|
+
/** Returns number of rows evicted. */
|
|
48
|
+
sweepExpired() {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
const result = db.prepare("DELETE FROM working_files WHERE claim_until < strftime('%Y-%m-%dT%H:%M:%SZ', 'now')").run();
|
|
51
|
+
const evicted = Number(result.changes ?? 0);
|
|
52
|
+
if (this.metrics) {
|
|
53
|
+
const count = db.prepare("SELECT COUNT(*) AS c FROM working_files").get().c;
|
|
54
|
+
this.metrics.workingFilesActive.set(count);
|
|
55
|
+
}
|
|
56
|
+
return evicted;
|
|
57
|
+
}
|
|
58
|
+
/** Called when an agent goes offline (MQTT LWT). Returns rows deleted. */
|
|
59
|
+
clearForAgent(agentId) {
|
|
60
|
+
const db = getDb();
|
|
61
|
+
const result = db.prepare("DELETE FROM working_files WHERE agent_id = ?").run(agentId);
|
|
62
|
+
return Number(result.changes ?? 0);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Background sweeper. unref() so it doesn't keep the loop alive at shutdown.
|
|
66
|
+
* Idempotent — second call is a no-op until stopSweeper().
|
|
67
|
+
*/
|
|
68
|
+
startSweeper(intervalMs = 60000) {
|
|
69
|
+
if (this.sweeperHandle)
|
|
70
|
+
return;
|
|
71
|
+
this.sweeperHandle = setInterval(() => {
|
|
72
|
+
try {
|
|
73
|
+
const evicted = this.sweepExpired();
|
|
74
|
+
if (evicted > 0)
|
|
75
|
+
this.log.info({ evicted }, "working_files sweep");
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.log.warn({ err }, "working_files sweep failed");
|
|
79
|
+
}
|
|
80
|
+
}, intervalMs);
|
|
81
|
+
if (typeof this.sweeperHandle.unref === "function")
|
|
82
|
+
this.sweeperHandle.unref();
|
|
83
|
+
}
|
|
84
|
+
stopSweeper() {
|
|
85
|
+
if (this.sweeperHandle) {
|
|
86
|
+
clearInterval(this.sweeperHandle);
|
|
87
|
+
this.sweeperHandle = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Read in-flight files map: file_path → set<agent_id>, excluding caller. */
|
|
91
|
+
getIndex(filePaths, excludeAgentId) {
|
|
92
|
+
const index = new Map();
|
|
93
|
+
if (filePaths.length === 0)
|
|
94
|
+
return index;
|
|
95
|
+
const db = getDb();
|
|
96
|
+
const placeholders = filePaths.map(() => "?").join(",");
|
|
97
|
+
const rows = db.prepare(`SELECT DISTINCT file_path, agent_id FROM working_files
|
|
98
|
+
WHERE file_path IN (${placeholders})
|
|
99
|
+
AND agent_id != ?
|
|
100
|
+
AND claim_until > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')`).all(...filePaths, excludeAgentId);
|
|
101
|
+
for (const r of rows) {
|
|
102
|
+
let set = index.get(r.file_path);
|
|
103
|
+
if (!set) {
|
|
104
|
+
set = new Set();
|
|
105
|
+
index.set(r.file_path, set);
|
|
106
|
+
}
|
|
107
|
+
set.add(r.agent_id);
|
|
108
|
+
}
|
|
109
|
+
return index;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-coordinator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"mcpName": "io.github.swoofer/mcp-coordinator",
|
|
5
5
|
"description": "Embedded MQTT broker + MCP server for multi-agent coordination",
|
|
6
6
|
"type": "module",
|
|
@@ -77,6 +77,23 @@
|
|
|
77
77
|
"typescript": "^5.7.0",
|
|
78
78
|
"vitest": "^4.1.0"
|
|
79
79
|
},
|
|
80
|
+
"optionalDependencies": {
|
|
81
|
+
"tree-sitter": "^0.21.1",
|
|
82
|
+
"tree-sitter-typescript": "^0.21.2",
|
|
83
|
+
"tree-sitter-javascript": "^0.21.4",
|
|
84
|
+
"tree-sitter-python": "^0.21.0",
|
|
85
|
+
"tree-sitter-go": "^0.21.0",
|
|
86
|
+
"tree-sitter-rust": "^0.21.2",
|
|
87
|
+
"tree-sitter-java": "^0.21.0",
|
|
88
|
+
"tree-sitter-c-sharp": "^0.21.3",
|
|
89
|
+
"tree-sitter-c": "^0.21.0",
|
|
90
|
+
"tree-sitter-cpp": "^0.22.0",
|
|
91
|
+
"tree-sitter-ruby": "^0.21.0",
|
|
92
|
+
"tree-sitter-php": "^0.22.0",
|
|
93
|
+
"tree-sitter-kotlin": "^0.3.0",
|
|
94
|
+
"tree-sitter-swift": "^0.6.0",
|
|
95
|
+
"tree-sitter-bash": "^0.21.0"
|
|
96
|
+
},
|
|
80
97
|
"engines": {
|
|
81
98
|
"node": ">=20"
|
|
82
99
|
}
|