@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.
Files changed (2) hide show
  1. package/dist/mcp-server.mjs +95 -62
  2. package/package.json +1 -1
@@ -877,32 +877,72 @@ ${notepad}`;
877
877
  }
878
878
  // --- Decision & Orchestration ---
879
879
  /**
880
- * Check if two file paths conflict hierarchically.
881
- * A lock on a directory blocks locks on any file within it, and vice versa.
882
- * Examples:
883
- * pathsConflict("/foo/bar", "/foo/bar/baz.ts") => true (parent blocks child)
884
- * pathsConflict("/foo/bar/baz.ts", "/foo/bar") => true (child blocks parent)
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 pathsConflict(pathA, pathB) {
889
- const a = pathA.replace(/\/+$/, "");
890
- const b = pathB.replace(/\/+$/, "");
891
- if (a === b) return true;
892
- if (b.startsWith(a + "/")) return true;
893
- if (a.startsWith(b + "/")) return true;
894
- return false;
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
- * Find any existing lock that conflicts hierarchically with the requested path.
898
- * Skips locks owned by the same agent and stale locks.
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
- findHierarchicalConflict(requestedPath, requestingAgent, locks) {
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
- if (_NerveCenter.pathsConflict(requestedPath, lock.filePath)) {
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", filePath, agentId, result.current_lock?.agent_id, intent);
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 '${filePath}' is locked by another agent`,
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", filePath, agentId, void 0, intent);
987
+ this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
934
988
  await this.appendToNotepad(`
935
- - [LOCK] ${agentId} locked ${filePath}
989
+ - [LOCK] ${agentId} locked ${normalizedPath}
936
990
  Intent: ${intent}`);
937
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
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", filePath, agentId, blockingAgent, intent);
1004
+ this.logLockEvent("BLOCKED", normalizedPath, agentId, blockingAgent, intent);
951
1005
  return {
952
1006
  status: "REQUIRES_ORCHESTRATION",
953
- message: `File '${filePath}' is locked by another agent`
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: filePath,
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", filePath, agentId, row.owner_id, intent);
1027
+ this.logLockEvent("BLOCKED", normalizedPath, agentId, row.owner_id, intent);
994
1028
  return {
995
1029
  status: "REQUIRES_ORCHESTRATION",
996
- message: `Conflict: File '${filePath}' is locked by '${row.owner_id}'`,
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", filePath, agentId, void 0, intent);
1039
+ this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
1006
1040
  await this.appendToNotepad(`
1007
- - [LOCK] ${agentId} locked ${filePath}
1041
+ - [LOCK] ${agentId} locked ${normalizedPath}
1008
1042
  Intent: ${intent}`);
1009
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
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.findHierarchicalConflict(filePath, agentId, allLocks);
1049
+ const conflict = this.findExactConflict(filePath, agentId, allLocks);
1016
1050
  if (conflict) {
1017
- logger.info(`[proposeFileAccess] Hierarchical conflict (local): '${filePath}' overlaps with locked '${conflict.filePath}' (owner: ${conflict.agentId})`);
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: '${filePath}' overlaps with '${conflict.filePath}' locked by '${conflict.agentId}'`,
1054
+ message: `Conflict: File '${normalizedPath}' is locked by '${conflict.agentId}'`,
1022
1055
  currentLock: conflict
1023
1056
  };
1024
1057
  }
1025
- this.state.locks[filePath] = { agentId, filePath, intent, userPrompt, timestamp: Date.now() };
1058
+ this.state.locks[normalizedPath] = { agentId, filePath: normalizedPath, intent, userPrompt, timestamp: Date.now() };
1026
1059
  await this.saveState();
1027
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
1060
+ this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
1028
1061
  await this.appendToNotepad(`
1029
- - [LOCK] ${agentId} locked ${filePath}
1062
+ - [LOCK] ${agentId} locked ${normalizedPath}
1030
1063
  Intent: ${intent}`);
1031
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
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 `REQUIRES_ORCHESTRATION` if someone else is editing.\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- 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.",
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: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@virsanghavi/axis-server",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Axis MCP Server CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {