@virsanghavi/axis-server 1.8.1 → 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.
Files changed (2) hide show
  1. package/dist/mcp-server.mjs +67 -91
  2. package/package.json +1 -1
@@ -879,11 +879,9 @@ ${notepad}`;
879
879
  /**
880
880
  * Normalize a lock path to be relative to the project root.
881
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)
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
886
  static normalizeLockPath(filePath) {
889
887
  let normalized = filePath.replace(/\/+$/, "");
@@ -897,59 +895,54 @@ ${notepad}`;
897
895
  return normalized;
898
896
  }
899
897
  /**
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.
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
905
906
  */
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) {
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(".")) {
915
926
  return {
916
927
  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.`
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.`
918
929
  };
919
930
  }
920
931
  return { valid: true };
921
932
  }
922
933
  /**
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)
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.
931
937
  */
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) {
938
+ findExactConflict(requestedPath, requestingAgent, locks) {
946
939
  const normalizedRequested = _NerveCenter.normalizeLockPath(requestedPath);
947
940
  for (const lock of locks) {
948
941
  if (lock.agentId === requestingAgent) continue;
949
942
  const isStale = Date.now() - lock.timestamp > this.lockTimeout;
950
943
  if (isStale) continue;
951
944
  const normalizedLock = _NerveCenter.normalizeLockPath(lock.filePath);
952
- if (_NerveCenter.pathsConflict(normalizedRequested, normalizedLock)) {
945
+ if (normalizedRequested === normalizedLock) {
953
946
  return lock;
954
947
  }
955
948
  }
@@ -960,38 +953,42 @@ ${notepad}`;
960
953
  logger.info(`[proposeFileAccess] Starting - agentId: ${agentId}, filePath: ${filePath}`);
961
954
  const normalizedPath = _NerveCenter.normalizeLockPath(filePath);
962
955
  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}`);
956
+ const fileCheck = await _NerveCenter.validateFileOnly(filePath);
957
+ if (!fileCheck.valid) {
958
+ logger.warn(`[proposeFileAccess] REJECTED \u2014 not a file: ${fileCheck.reason}`);
966
959
  return {
967
960
  status: "REJECTED",
968
- message: scopeCheck.reason
961
+ message: fileCheck.reason
969
962
  };
970
963
  }
971
964
  if (this.contextManager.apiUrl) {
972
965
  try {
973
966
  const result = await this.callCoordination("locks", "POST", {
974
967
  action: "lock",
975
- filePath,
968
+ filePath: normalizedPath,
976
969
  agentId,
977
970
  intent,
978
971
  userPrompt
979
972
  });
980
973
  if (result.status === "DENIED") {
981
974
  logger.info(`[proposeFileAccess] DENIED by server: ${result.message}`);
982
- this.logLockEvent("BLOCKED", filePath, agentId, result.current_lock?.agent_id, intent);
975
+ this.logLockEvent("BLOCKED", normalizedPath, agentId, result.current_lock?.agent_id, intent);
983
976
  return {
984
977
  status: "REQUIRES_ORCHESTRATION",
985
- message: result.message || `File '${filePath}' is locked by another agent`,
978
+ message: result.message || `File '${normalizedPath}' is locked by another agent`,
986
979
  currentLock: result.current_lock
987
980
  };
988
981
  }
982
+ if (result.status === "REJECTED") {
983
+ logger.warn(`[proposeFileAccess] REJECTED by server: ${result.message}`);
984
+ return { status: "REJECTED", message: result.message };
985
+ }
989
986
  logger.info(`[proposeFileAccess] GRANTED by server`);
990
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
987
+ this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
991
988
  await this.appendToNotepad(`
992
- - [LOCK] ${agentId} locked ${filePath}
989
+ - [LOCK] ${agentId} locked ${normalizedPath}
993
990
  Intent: ${intent}`);
994
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
991
+ return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
995
992
  } catch (e) {
996
993
  if (e.message && e.message.includes("409")) {
997
994
  logger.info(`[proposeFileAccess] Lock conflict (409)`);
@@ -1004,10 +1001,10 @@ ${notepad}`;
1004
1001
  }
1005
1002
  } catch {
1006
1003
  }
1007
- this.logLockEvent("BLOCKED", filePath, agentId, blockingAgent, intent);
1004
+ this.logLockEvent("BLOCKED", normalizedPath, agentId, blockingAgent, intent);
1008
1005
  return {
1009
1006
  status: "REQUIRES_ORCHESTRATION",
1010
- message: `File '${filePath}' is locked by another agent`
1007
+ message: `File '${normalizedPath}' is locked by another agent`
1011
1008
  };
1012
1009
  }
1013
1010
  logger.error(`[proposeFileAccess] API lock failed: ${e.message}`, e);
@@ -1016,29 +1013,9 @@ ${notepad}`;
1016
1013
  }
1017
1014
  if (this.useSupabase && this.supabase && this._projectId) {
1018
1015
  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
- }
1039
1016
  const { data, error } = await this.supabase.rpc("try_acquire_lock", {
1040
1017
  p_project_id: this._projectId,
1041
- p_file_path: filePath,
1018
+ p_file_path: normalizedPath,
1042
1019
  p_agent_id: agentId,
1043
1020
  p_intent: intent,
1044
1021
  p_user_prompt: userPrompt,
@@ -1047,45 +1024,44 @@ ${notepad}`;
1047
1024
  if (error) throw error;
1048
1025
  const row = Array.isArray(data) ? data[0] : data;
1049
1026
  if (row && row.status === "DENIED") {
1050
- this.logLockEvent("BLOCKED", filePath, agentId, row.owner_id, intent);
1027
+ this.logLockEvent("BLOCKED", normalizedPath, agentId, row.owner_id, intent);
1051
1028
  return {
1052
1029
  status: "REQUIRES_ORCHESTRATION",
1053
- message: `Conflict: File '${filePath}' is locked by '${row.owner_id}'`,
1030
+ message: `Conflict: File '${normalizedPath}' is locked by '${row.owner_id}'`,
1054
1031
  currentLock: {
1055
1032
  agentId: row.owner_id,
1056
- filePath,
1033
+ filePath: normalizedPath,
1057
1034
  intent: row.intent,
1058
1035
  timestamp: row.updated_at ? Date.parse(row.updated_at) : Date.now()
1059
1036
  }
1060
1037
  };
1061
1038
  }
1062
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
1039
+ this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
1063
1040
  await this.appendToNotepad(`
1064
- - [LOCK] ${agentId} locked ${filePath}
1041
+ - [LOCK] ${agentId} locked ${normalizedPath}
1065
1042
  Intent: ${intent}`);
1066
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
1043
+ return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
1067
1044
  } catch (e) {
1068
1045
  logger.warn("[NerveCenter] Lock RPC failed. Falling back to local.", e);
1069
1046
  }
1070
1047
  }
1071
1048
  const allLocks = Object.values(this.state.locks);
1072
- const conflict = this.findHierarchicalConflict(filePath, agentId, allLocks);
1049
+ const conflict = this.findExactConflict(filePath, agentId, allLocks);
1073
1050
  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);
1051
+ this.logLockEvent("BLOCKED", normalizedPath, agentId, conflict.agentId, intent);
1076
1052
  return {
1077
1053
  status: "REQUIRES_ORCHESTRATION",
1078
- message: `Conflict: '${filePath}' overlaps with '${conflict.filePath}' locked by '${conflict.agentId}'`,
1054
+ message: `Conflict: File '${normalizedPath}' is locked by '${conflict.agentId}'`,
1079
1055
  currentLock: conflict
1080
1056
  };
1081
1057
  }
1082
- this.state.locks[filePath] = { agentId, filePath, intent, userPrompt, timestamp: Date.now() };
1058
+ this.state.locks[normalizedPath] = { agentId, filePath: normalizedPath, intent, userPrompt, timestamp: Date.now() };
1083
1059
  await this.saveState();
1084
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
1060
+ this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
1085
1061
  await this.appendToNotepad(`
1086
- - [LOCK] ${agentId} locked ${filePath}
1062
+ - [LOCK] ${agentId} locked ${normalizedPath}
1087
1063
  Intent: ${intent}`);
1088
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
1064
+ return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
1089
1065
  });
1090
1066
  }
1091
1067
  async updateSharedContext(text, agentId) {
@@ -2069,7 +2045,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2069
2045
  // --- Decision & Orchestration ---
2070
2046
  {
2071
2047
  name: "propose_file_access",
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.",
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.",
2073
2049
  inputSchema: {
2074
2050
  type: "object",
2075
2051
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@virsanghavi/axis-server",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Axis MCP Server CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {