@virsanghavi/axis-server 1.7.2 → 1.8.1

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.
Files changed (2) hide show
  1. package/dist/mcp-server.mjs +121 -13
  2. package/package.json +1 -1
@@ -284,7 +284,7 @@ var CircuitOpenError = class extends Error {
284
284
  this.name = "CircuitOpenError";
285
285
  }
286
286
  };
287
- var NerveCenter = class {
287
+ var NerveCenter = class _NerveCenter {
288
288
  mutex;
289
289
  state;
290
290
  contextManager;
@@ -876,9 +876,98 @@ ${notepad}`;
876
876
  }
877
877
  }
878
878
  // --- Decision & Orchestration ---
879
+ /**
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.
883
+ * Examples (assuming cwd = /Users/vir/Projects/MyApp):
884
+ * "/Users/vir/Projects/MyApp/src/api/v1/route.ts" => "src/api/v1/route.ts"
885
+ * "src/api/v1/route.ts" => "src/api/v1/route.ts"
886
+ * "/Users/vir/Projects/MyApp/" => "" (project root)
887
+ */
888
+ static normalizeLockPath(filePath) {
889
+ let normalized = filePath.replace(/\/+$/, "");
890
+ const cwd = process.cwd().replace(/\/+$/, "");
891
+ if (normalized.startsWith(cwd + "/")) {
892
+ normalized = normalized.slice(cwd.length + 1);
893
+ } else if (normalized === cwd) {
894
+ normalized = "";
895
+ }
896
+ normalized = normalized.replace(/^\/+/, "");
897
+ return normalized;
898
+ }
899
+ /**
900
+ * Validate that a lock path is not overly broad.
901
+ * Directory locks (last segment has no file extension) must have at least
902
+ * MIN_DIR_LOCK_DEPTH segments from the project root.
903
+ * This prevents agents from locking huge swaths of the codebase like
904
+ * "src/" or "frontend/" which would block every other agent.
905
+ */
906
+ static MIN_DIR_LOCK_DEPTH = 2;
907
+ static validateLockScope(normalizedPath) {
908
+ if (!normalizedPath || normalizedPath === "." || normalizedPath === "/") {
909
+ return { valid: false, reason: "Cannot lock the entire project root. Lock specific files or subdirectories instead." };
910
+ }
911
+ const segments = normalizedPath.split("/").filter(Boolean);
912
+ const lastSegment = segments[segments.length - 1] || "";
913
+ const hasExtension = lastSegment.includes(".");
914
+ if (!hasExtension && segments.length < _NerveCenter.MIN_DIR_LOCK_DEPTH) {
915
+ return {
916
+ valid: false,
917
+ reason: `Directory lock '${normalizedPath}' is too broad (depth ${segments.length}, minimum ${_NerveCenter.MIN_DIR_LOCK_DEPTH}). Lock a more specific subdirectory or individual files instead.`
918
+ };
919
+ }
920
+ return { valid: true };
921
+ }
922
+ /**
923
+ * Check if two file paths conflict hierarchically.
924
+ * Both paths should be normalized (relative to project root) before comparison.
925
+ * A lock on a directory blocks locks on any file within it, and vice versa.
926
+ * Examples:
927
+ * pathsConflict("src/api", "src/api/route.ts") => true (parent blocks child)
928
+ * pathsConflict("src/api/route.ts", "src/api") => true (child blocks parent)
929
+ * pathsConflict("src/api", "src/api") => true (exact match)
930
+ * pathsConflict("src/api", "src/lib") => false (siblings)
931
+ */
932
+ static pathsConflict(pathA, pathB) {
933
+ const a = pathA.replace(/\/+$/, "");
934
+ const b = pathB.replace(/\/+$/, "");
935
+ if (a === b) return true;
936
+ if (b.startsWith(a + "/")) return true;
937
+ if (a.startsWith(b + "/")) return true;
938
+ return false;
939
+ }
940
+ /**
941
+ * Find any existing lock that conflicts hierarchically with the requested path.
942
+ * Skips locks owned by the same agent and stale locks.
943
+ * All paths are normalized before comparison.
944
+ */
945
+ findHierarchicalConflict(requestedPath, requestingAgent, locks) {
946
+ const normalizedRequested = _NerveCenter.normalizeLockPath(requestedPath);
947
+ for (const lock of locks) {
948
+ if (lock.agentId === requestingAgent) continue;
949
+ const isStale = Date.now() - lock.timestamp > this.lockTimeout;
950
+ if (isStale) continue;
951
+ const normalizedLock = _NerveCenter.normalizeLockPath(lock.filePath);
952
+ if (_NerveCenter.pathsConflict(normalizedRequested, normalizedLock)) {
953
+ return lock;
954
+ }
955
+ }
956
+ return null;
957
+ }
879
958
  async proposeFileAccess(agentId, filePath, intent, userPrompt) {
880
959
  return await this.mutex.runExclusive(async () => {
881
960
  logger.info(`[proposeFileAccess] Starting - agentId: ${agentId}, filePath: ${filePath}`);
961
+ const normalizedPath = _NerveCenter.normalizeLockPath(filePath);
962
+ logger.info(`[proposeFileAccess] Normalized path: '${normalizedPath}' (from '${filePath}')`);
963
+ const scopeCheck = _NerveCenter.validateLockScope(normalizedPath);
964
+ if (!scopeCheck.valid) {
965
+ logger.warn(`[proposeFileAccess] REJECTED \u2014 scope too broad: ${scopeCheck.reason}`);
966
+ return {
967
+ status: "REJECTED",
968
+ message: scopeCheck.reason
969
+ };
970
+ }
882
971
  if (this.contextManager.apiUrl) {
883
972
  try {
884
973
  const result = await this.callCoordination("locks", "POST", {
@@ -927,6 +1016,26 @@ ${notepad}`;
927
1016
  }
928
1017
  if (this.useSupabase && this.supabase && this._projectId) {
929
1018
  try {
1019
+ const { data: existingLocks } = await this.supabase.from("locks").select("agent_id, file_path, intent, updated_at").eq("project_id", this._projectId);
1020
+ if (existingLocks && existingLocks.length > 0) {
1021
+ const asFileLocks = existingLocks.map((row2) => ({
1022
+ agentId: row2.agent_id,
1023
+ filePath: row2.file_path,
1024
+ intent: row2.intent,
1025
+ userPrompt: "",
1026
+ timestamp: row2.updated_at ? Date.parse(row2.updated_at) : Date.now()
1027
+ }));
1028
+ const conflict2 = this.findHierarchicalConflict(filePath, agentId, asFileLocks);
1029
+ if (conflict2) {
1030
+ logger.info(`[proposeFileAccess] Hierarchical conflict: '${filePath}' overlaps with locked '${conflict2.filePath}' (owner: ${conflict2.agentId})`);
1031
+ this.logLockEvent("BLOCKED", filePath, agentId, conflict2.agentId, intent);
1032
+ return {
1033
+ status: "REQUIRES_ORCHESTRATION",
1034
+ message: `Conflict: '${filePath}' overlaps with '${conflict2.filePath}' locked by '${conflict2.agentId}'`,
1035
+ currentLock: conflict2
1036
+ };
1037
+ }
1038
+ }
930
1039
  const { data, error } = await this.supabase.rpc("try_acquire_lock", {
931
1040
  p_project_id: this._projectId,
932
1041
  p_file_path: filePath,
@@ -959,17 +1068,16 @@ ${notepad}`;
959
1068
  logger.warn("[NerveCenter] Lock RPC failed. Falling back to local.", e);
960
1069
  }
961
1070
  }
962
- const existing = Object.values(this.state.locks).find((l) => l.filePath === filePath);
963
- if (existing) {
964
- const isStale = Date.now() - existing.timestamp > this.lockTimeout;
965
- if (!isStale && existing.agentId !== agentId) {
966
- this.logLockEvent("BLOCKED", filePath, agentId, existing.agentId, intent);
967
- return {
968
- status: "REQUIRES_ORCHESTRATION",
969
- message: `Conflict: File '${filePath}' is currently locked by '${existing.agentId}'`,
970
- currentLock: existing
971
- };
972
- }
1071
+ const allLocks = Object.values(this.state.locks);
1072
+ const conflict = this.findHierarchicalConflict(filePath, agentId, allLocks);
1073
+ if (conflict) {
1074
+ logger.info(`[proposeFileAccess] Hierarchical conflict (local): '${filePath}' overlaps with locked '${conflict.filePath}' (owner: ${conflict.agentId})`);
1075
+ this.logLockEvent("BLOCKED", filePath, agentId, conflict.agentId, intent);
1076
+ return {
1077
+ status: "REQUIRES_ORCHESTRATION",
1078
+ message: `Conflict: '${filePath}' overlaps with '${conflict.filePath}' locked by '${conflict.agentId}'`,
1079
+ currentLock: conflict
1080
+ };
973
1081
  }
974
1082
  this.state.locks[filePath] = { agentId, filePath, intent, userPrompt, timestamp: Date.now() };
975
1083
  await this.saveState();
@@ -1961,7 +2069,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1961
2069
  // --- Decision & Orchestration ---
1962
2070
  {
1963
2071
  name: "propose_file_access",
1964
- 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 `REQUIRES_ORCHESTRATION` if someone else is editing.\n- Usage: Provide your `agentId` (e.g., 'cursor-agent'), `filePath` (absolute), 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.",
2072
+ 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, `REQUIRES_ORCHESTRATION` if someone else is editing, or `REJECTED` if the lock scope is too broad.\n- **Hierarchical matching**: Locking a directory also blocks locks on files within it, and vice versa. E.g. locking `src/api/` blocks `src/api/auth/login.ts`.\n- **Scope guard**: Overly broad directory locks are rejected. You cannot lock top-level directories like `src/` or `frontend/` \u2014 lock specific subdirectories (e.g. `src/api/auth/`) or individual files instead.\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), 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.",
1965
2073
  inputSchema: {
1966
2074
  type: "object",
1967
2075
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@virsanghavi/axis-server",
3
- "version": "1.7.2",
3
+ "version": "1.8.1",
4
4
  "description": "Axis MCP Server CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {