@virsanghavi/axis-server 1.8.0 → 1.9.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/dist/mcp-server.mjs +95 -62
- package/package.json +1 -1
package/dist/mcp-server.mjs
CHANGED
|
@@ -877,32 +877,72 @@ ${notepad}`;
|
|
|
877
877
|
}
|
|
878
878
|
// --- Decision & Orchestration ---
|
|
879
879
|
/**
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
* pathsConflict("/foo/bar", "/foo/bar") => true (exact match)
|
|
886
|
-
* pathsConflict("/foo/bar", "/foo/baz") => false (siblings)
|
|
880
|
+
* Normalize a lock path to be relative to the project root.
|
|
881
|
+
* Strips the project root prefix (process.cwd()) so that absolute and relative
|
|
882
|
+
* paths resolve to the same key. This ensures that:
|
|
883
|
+
* "/Users/vir/Projects/MyApp/src/api/route.ts" and "src/api/route.ts"
|
|
884
|
+
* are treated as the same lock.
|
|
887
885
|
*/
|
|
888
|
-
static
|
|
889
|
-
|
|
890
|
-
const
|
|
891
|
-
if (
|
|
892
|
-
|
|
893
|
-
if (
|
|
894
|
-
|
|
886
|
+
static normalizeLockPath(filePath) {
|
|
887
|
+
let normalized = filePath.replace(/\/+$/, "");
|
|
888
|
+
const cwd = process.cwd().replace(/\/+$/, "");
|
|
889
|
+
if (normalized.startsWith(cwd + "/")) {
|
|
890
|
+
normalized = normalized.slice(cwd.length + 1);
|
|
891
|
+
} else if (normalized === cwd) {
|
|
892
|
+
normalized = "";
|
|
893
|
+
}
|
|
894
|
+
normalized = normalized.replace(/^\/+/, "");
|
|
895
|
+
return normalized;
|
|
895
896
|
}
|
|
896
897
|
/**
|
|
897
|
-
*
|
|
898
|
-
*
|
|
898
|
+
* Validate that a lock targets an individual file, not a directory.
|
|
899
|
+
* Agents must lock specific files — directory locks are rejected because
|
|
900
|
+
* they block all other agents from working on ANY file in that tree,
|
|
901
|
+
* even for completely unrelated features.
|
|
902
|
+
*
|
|
903
|
+
* Detection strategy:
|
|
904
|
+
* 1. If the path exists on disk, use fs.stat to check (handles extensionless files like Makefile)
|
|
905
|
+
* 2. If the path doesn't exist, use file extension heuristic
|
|
899
906
|
*/
|
|
900
|
-
|
|
907
|
+
static async validateFileOnly(filePath) {
|
|
908
|
+
const normalized = _NerveCenter.normalizeLockPath(filePath);
|
|
909
|
+
if (!normalized || normalized === "." || normalized === "/") {
|
|
910
|
+
return { valid: false, reason: "Cannot lock the project root. Lock individual files instead." };
|
|
911
|
+
}
|
|
912
|
+
const absolutePath = path2.isAbsolute(filePath) ? filePath : path2.join(process.cwd(), filePath);
|
|
913
|
+
try {
|
|
914
|
+
const stat = await fs2.stat(absolutePath);
|
|
915
|
+
if (stat.isDirectory()) {
|
|
916
|
+
return {
|
|
917
|
+
valid: false,
|
|
918
|
+
reason: `'${normalized}' is a directory. Lock individual files instead \u2014 directory locks block all agents from the entire tree, preventing parallel work on different features.`
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
return { valid: true };
|
|
922
|
+
} catch {
|
|
923
|
+
}
|
|
924
|
+
const lastSegment = normalized.split("/").filter(Boolean).pop() || "";
|
|
925
|
+
if (!lastSegment.includes(".")) {
|
|
926
|
+
return {
|
|
927
|
+
valid: false,
|
|
928
|
+
reason: `'${normalized}' looks like a directory (no file extension). Lock individual files instead \u2014 directory locks block all agents from the entire tree, preventing parallel work on different features.`
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
return { valid: true };
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Find an existing lock that conflicts with the requested path (exact match).
|
|
935
|
+
* Paths are normalized before comparison so absolute and relative paths
|
|
936
|
+
* targeting the same file are correctly detected as conflicts.
|
|
937
|
+
*/
|
|
938
|
+
findExactConflict(requestedPath, requestingAgent, locks) {
|
|
939
|
+
const normalizedRequested = _NerveCenter.normalizeLockPath(requestedPath);
|
|
901
940
|
for (const lock of locks) {
|
|
902
941
|
if (lock.agentId === requestingAgent) continue;
|
|
903
942
|
const isStale = Date.now() - lock.timestamp > this.lockTimeout;
|
|
904
943
|
if (isStale) continue;
|
|
905
|
-
|
|
944
|
+
const normalizedLock = _NerveCenter.normalizeLockPath(lock.filePath);
|
|
945
|
+
if (normalizedRequested === normalizedLock) {
|
|
906
946
|
return lock;
|
|
907
947
|
}
|
|
908
948
|
}
|
|
@@ -911,30 +951,44 @@ ${notepad}`;
|
|
|
911
951
|
async proposeFileAccess(agentId, filePath, intent, userPrompt) {
|
|
912
952
|
return await this.mutex.runExclusive(async () => {
|
|
913
953
|
logger.info(`[proposeFileAccess] Starting - agentId: ${agentId}, filePath: ${filePath}`);
|
|
954
|
+
const normalizedPath = _NerveCenter.normalizeLockPath(filePath);
|
|
955
|
+
logger.info(`[proposeFileAccess] Normalized path: '${normalizedPath}' (from '${filePath}')`);
|
|
956
|
+
const fileCheck = await _NerveCenter.validateFileOnly(filePath);
|
|
957
|
+
if (!fileCheck.valid) {
|
|
958
|
+
logger.warn(`[proposeFileAccess] REJECTED \u2014 not a file: ${fileCheck.reason}`);
|
|
959
|
+
return {
|
|
960
|
+
status: "REJECTED",
|
|
961
|
+
message: fileCheck.reason
|
|
962
|
+
};
|
|
963
|
+
}
|
|
914
964
|
if (this.contextManager.apiUrl) {
|
|
915
965
|
try {
|
|
916
966
|
const result = await this.callCoordination("locks", "POST", {
|
|
917
967
|
action: "lock",
|
|
918
|
-
filePath,
|
|
968
|
+
filePath: normalizedPath,
|
|
919
969
|
agentId,
|
|
920
970
|
intent,
|
|
921
971
|
userPrompt
|
|
922
972
|
});
|
|
923
973
|
if (result.status === "DENIED") {
|
|
924
974
|
logger.info(`[proposeFileAccess] DENIED by server: ${result.message}`);
|
|
925
|
-
this.logLockEvent("BLOCKED",
|
|
975
|
+
this.logLockEvent("BLOCKED", normalizedPath, agentId, result.current_lock?.agent_id, intent);
|
|
926
976
|
return {
|
|
927
977
|
status: "REQUIRES_ORCHESTRATION",
|
|
928
|
-
message: result.message || `File '${
|
|
978
|
+
message: result.message || `File '${normalizedPath}' is locked by another agent`,
|
|
929
979
|
currentLock: result.current_lock
|
|
930
980
|
};
|
|
931
981
|
}
|
|
982
|
+
if (result.status === "REJECTED") {
|
|
983
|
+
logger.warn(`[proposeFileAccess] REJECTED by server: ${result.message}`);
|
|
984
|
+
return { status: "REJECTED", message: result.message };
|
|
985
|
+
}
|
|
932
986
|
logger.info(`[proposeFileAccess] GRANTED by server`);
|
|
933
|
-
this.logLockEvent("GRANTED",
|
|
987
|
+
this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
|
|
934
988
|
await this.appendToNotepad(`
|
|
935
|
-
- [LOCK] ${agentId} locked ${
|
|
989
|
+
- [LOCK] ${agentId} locked ${normalizedPath}
|
|
936
990
|
Intent: ${intent}`);
|
|
937
|
-
return { status: "GRANTED", message: `Access granted for ${
|
|
991
|
+
return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
|
|
938
992
|
} catch (e) {
|
|
939
993
|
if (e.message && e.message.includes("409")) {
|
|
940
994
|
logger.info(`[proposeFileAccess] Lock conflict (409)`);
|
|
@@ -947,10 +1001,10 @@ ${notepad}`;
|
|
|
947
1001
|
}
|
|
948
1002
|
} catch {
|
|
949
1003
|
}
|
|
950
|
-
this.logLockEvent("BLOCKED",
|
|
1004
|
+
this.logLockEvent("BLOCKED", normalizedPath, agentId, blockingAgent, intent);
|
|
951
1005
|
return {
|
|
952
1006
|
status: "REQUIRES_ORCHESTRATION",
|
|
953
|
-
message: `File '${
|
|
1007
|
+
message: `File '${normalizedPath}' is locked by another agent`
|
|
954
1008
|
};
|
|
955
1009
|
}
|
|
956
1010
|
logger.error(`[proposeFileAccess] API lock failed: ${e.message}`, e);
|
|
@@ -959,29 +1013,9 @@ ${notepad}`;
|
|
|
959
1013
|
}
|
|
960
1014
|
if (this.useSupabase && this.supabase && this._projectId) {
|
|
961
1015
|
try {
|
|
962
|
-
const { data: existingLocks } = await this.supabase.from("locks").select("agent_id, file_path, intent, updated_at").eq("project_id", this._projectId);
|
|
963
|
-
if (existingLocks && existingLocks.length > 0) {
|
|
964
|
-
const asFileLocks = existingLocks.map((row2) => ({
|
|
965
|
-
agentId: row2.agent_id,
|
|
966
|
-
filePath: row2.file_path,
|
|
967
|
-
intent: row2.intent,
|
|
968
|
-
userPrompt: "",
|
|
969
|
-
timestamp: row2.updated_at ? Date.parse(row2.updated_at) : Date.now()
|
|
970
|
-
}));
|
|
971
|
-
const conflict2 = this.findHierarchicalConflict(filePath, agentId, asFileLocks);
|
|
972
|
-
if (conflict2) {
|
|
973
|
-
logger.info(`[proposeFileAccess] Hierarchical conflict: '${filePath}' overlaps with locked '${conflict2.filePath}' (owner: ${conflict2.agentId})`);
|
|
974
|
-
this.logLockEvent("BLOCKED", filePath, agentId, conflict2.agentId, intent);
|
|
975
|
-
return {
|
|
976
|
-
status: "REQUIRES_ORCHESTRATION",
|
|
977
|
-
message: `Conflict: '${filePath}' overlaps with '${conflict2.filePath}' locked by '${conflict2.agentId}'`,
|
|
978
|
-
currentLock: conflict2
|
|
979
|
-
};
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
1016
|
const { data, error } = await this.supabase.rpc("try_acquire_lock", {
|
|
983
1017
|
p_project_id: this._projectId,
|
|
984
|
-
p_file_path:
|
|
1018
|
+
p_file_path: normalizedPath,
|
|
985
1019
|
p_agent_id: agentId,
|
|
986
1020
|
p_intent: intent,
|
|
987
1021
|
p_user_prompt: userPrompt,
|
|
@@ -990,45 +1024,44 @@ ${notepad}`;
|
|
|
990
1024
|
if (error) throw error;
|
|
991
1025
|
const row = Array.isArray(data) ? data[0] : data;
|
|
992
1026
|
if (row && row.status === "DENIED") {
|
|
993
|
-
this.logLockEvent("BLOCKED",
|
|
1027
|
+
this.logLockEvent("BLOCKED", normalizedPath, agentId, row.owner_id, intent);
|
|
994
1028
|
return {
|
|
995
1029
|
status: "REQUIRES_ORCHESTRATION",
|
|
996
|
-
message: `Conflict: File '${
|
|
1030
|
+
message: `Conflict: File '${normalizedPath}' is locked by '${row.owner_id}'`,
|
|
997
1031
|
currentLock: {
|
|
998
1032
|
agentId: row.owner_id,
|
|
999
|
-
filePath,
|
|
1033
|
+
filePath: normalizedPath,
|
|
1000
1034
|
intent: row.intent,
|
|
1001
1035
|
timestamp: row.updated_at ? Date.parse(row.updated_at) : Date.now()
|
|
1002
1036
|
}
|
|
1003
1037
|
};
|
|
1004
1038
|
}
|
|
1005
|
-
this.logLockEvent("GRANTED",
|
|
1039
|
+
this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
|
|
1006
1040
|
await this.appendToNotepad(`
|
|
1007
|
-
- [LOCK] ${agentId} locked ${
|
|
1041
|
+
- [LOCK] ${agentId} locked ${normalizedPath}
|
|
1008
1042
|
Intent: ${intent}`);
|
|
1009
|
-
return { status: "GRANTED", message: `Access granted for ${
|
|
1043
|
+
return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
|
|
1010
1044
|
} catch (e) {
|
|
1011
1045
|
logger.warn("[NerveCenter] Lock RPC failed. Falling back to local.", e);
|
|
1012
1046
|
}
|
|
1013
1047
|
}
|
|
1014
1048
|
const allLocks = Object.values(this.state.locks);
|
|
1015
|
-
const conflict = this.
|
|
1049
|
+
const conflict = this.findExactConflict(filePath, agentId, allLocks);
|
|
1016
1050
|
if (conflict) {
|
|
1017
|
-
|
|
1018
|
-
this.logLockEvent("BLOCKED", filePath, agentId, conflict.agentId, intent);
|
|
1051
|
+
this.logLockEvent("BLOCKED", normalizedPath, agentId, conflict.agentId, intent);
|
|
1019
1052
|
return {
|
|
1020
1053
|
status: "REQUIRES_ORCHESTRATION",
|
|
1021
|
-
message: `Conflict: '${
|
|
1054
|
+
message: `Conflict: File '${normalizedPath}' is locked by '${conflict.agentId}'`,
|
|
1022
1055
|
currentLock: conflict
|
|
1023
1056
|
};
|
|
1024
1057
|
}
|
|
1025
|
-
this.state.locks[
|
|
1058
|
+
this.state.locks[normalizedPath] = { agentId, filePath: normalizedPath, intent, userPrompt, timestamp: Date.now() };
|
|
1026
1059
|
await this.saveState();
|
|
1027
|
-
this.logLockEvent("GRANTED",
|
|
1060
|
+
this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
|
|
1028
1061
|
await this.appendToNotepad(`
|
|
1029
|
-
- [LOCK] ${agentId} locked ${
|
|
1062
|
+
- [LOCK] ${agentId} locked ${normalizedPath}
|
|
1030
1063
|
Intent: ${intent}`);
|
|
1031
|
-
return { status: "GRANTED", message: `Access granted for ${
|
|
1064
|
+
return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
|
|
1032
1065
|
});
|
|
1033
1066
|
}
|
|
1034
1067
|
async updateSharedContext(text, agentId) {
|
|
@@ -2012,7 +2045,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2012
2045
|
// --- Decision & Orchestration ---
|
|
2013
2046
|
{
|
|
2014
2047
|
name: "propose_file_access",
|
|
2015
|
-
description: "**CRITICAL: REQUEST FILE LOCK** \u2014 call this before EVERY file edit, no exceptions.\n- Checks if another agent currently holds a lock.\n- Returns `GRANTED` if safe to proceed, or `
|
|
2048
|
+
description: "**CRITICAL: REQUEST FILE LOCK** \u2014 call this before EVERY file edit, no exceptions.\n- Checks if another agent currently holds a lock on the same file.\n- Returns `GRANTED` if safe to proceed, `REQUIRES_ORCHESTRATION` if another agent has the file locked, or `REJECTED` if you tried to lock a directory.\n- **File-only locks**: You MUST lock individual files, not directories. Directory locks are rejected because they block all agents from the entire tree, preventing parallel work on different features. Lock each file you edit separately.\n- Paths are normalized relative to the project root, so absolute and relative paths are treated equivalently.\n- Usage: Provide your `agentId` (e.g., 'cursor-agent'), `filePath` (absolute or relative to a specific file), and `intent` (descriptive \u2014 e.g. 'Refactor auth to use JWT', NOT 'editing file').\n- Locks expire after 30 minutes. Use `force_unlock` only as a last resort for crashed agents.\n- **IMPORTANT**: Every lock you acquire MUST be released. Call `complete_job` when done with each task, and `finalize_session` before ending your session. Dangling locks block all other agents.",
|
|
2016
2049
|
inputSchema: {
|
|
2017
2050
|
type: "object",
|
|
2018
2051
|
properties: {
|