@virsanghavi/axis-server 1.8.1 → 1.9.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 +75 -93
  2. package/package.json +1 -1
@@ -827,7 +827,7 @@ var NerveCenter = class _NerveCenter {
827
827
  delete this.state.locks[filePath];
828
828
  await this.saveState();
829
829
  }
830
- this.logLockEvent("FORCE_UNLOCKED", filePath, "admin", void 0, reason);
830
+ await this.logLockEvent("FORCE_UNLOCKED", filePath, "admin", void 0, reason);
831
831
  await this.appendToNotepad(`
832
832
  - [FORCE UNLOCK] ${filePath} unlocked by admin. Reason: ${reason}`);
833
833
  return `File ${filePath} has been forcibly unlocked.`;
@@ -854,6 +854,7 @@ ${notepad}`;
854
854
  async logLockEvent(eventType, filePath, requestingAgent, blockingAgent, intent) {
855
855
  try {
856
856
  if (this.contextManager.apiUrl) {
857
+ logger.info(`[logLockEvent] Logging ${eventType} event via API for ${filePath} (agent: ${requestingAgent}, blocker: ${blockingAgent || "none"})`);
857
858
  await this.callCoordination("lock-events", "POST", {
858
859
  eventType,
859
860
  filePath,
@@ -861,7 +862,9 @@ ${notepad}`;
861
862
  blockingAgent: blockingAgent || null,
862
863
  intent: intent || null
863
864
  });
865
+ logger.info(`[logLockEvent] Successfully logged ${eventType} event`);
864
866
  } else if (this.useSupabase && this.supabase && this._projectId) {
867
+ logger.info(`[logLockEvent] Logging ${eventType} event via Supabase for ${filePath}`);
865
868
  await this.supabase.from("lock_events").insert({
866
869
  project_id: this._projectId,
867
870
  event_type: eventType,
@@ -870,20 +873,21 @@ ${notepad}`;
870
873
  blocking_agent: blockingAgent || null,
871
874
  intent: intent || null
872
875
  });
876
+ logger.info(`[logLockEvent] Successfully logged ${eventType} event`);
877
+ } else {
878
+ logger.warn(`[logLockEvent] No persistence backend available \u2014 ${eventType} event for ${filePath} will not be recorded`);
873
879
  }
874
880
  } catch (e) {
875
- logger.warn(`[logLockEvent] Failed to log ${eventType} event: ${e.message}`);
881
+ logger.error(`[logLockEvent] Failed to log ${eventType} event for ${filePath}: ${e.message}`);
876
882
  }
877
883
  }
878
884
  // --- Decision & Orchestration ---
879
885
  /**
880
886
  * Normalize a lock path to be relative to the project root.
881
887
  * 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)
888
+ * paths resolve to the same key. This ensures that:
889
+ * "/Users/vir/Projects/MyApp/src/api/route.ts" and "src/api/route.ts"
890
+ * are treated as the same lock.
887
891
  */
888
892
  static normalizeLockPath(filePath) {
889
893
  let normalized = filePath.replace(/\/+$/, "");
@@ -897,59 +901,54 @@ ${notepad}`;
897
901
  return normalized;
898
902
  }
899
903
  /**
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.
904
+ * Validate that a lock targets an individual file, not a directory.
905
+ * Agents must lock specific files directory locks are rejected because
906
+ * they block all other agents from working on ANY file in that tree,
907
+ * even for completely unrelated features.
908
+ *
909
+ * Detection strategy:
910
+ * 1. If the path exists on disk, use fs.stat to check (handles extensionless files like Makefile)
911
+ * 2. If the path doesn't exist, use file extension heuristic
905
912
  */
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) {
913
+ static async validateFileOnly(filePath) {
914
+ const normalized = _NerveCenter.normalizeLockPath(filePath);
915
+ if (!normalized || normalized === "." || normalized === "/") {
916
+ return { valid: false, reason: "Cannot lock the project root. Lock individual files instead." };
917
+ }
918
+ const absolutePath = path2.isAbsolute(filePath) ? filePath : path2.join(process.cwd(), filePath);
919
+ try {
920
+ const stat = await fs2.stat(absolutePath);
921
+ if (stat.isDirectory()) {
922
+ return {
923
+ valid: false,
924
+ 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.`
925
+ };
926
+ }
927
+ return { valid: true };
928
+ } catch {
929
+ }
930
+ const lastSegment = normalized.split("/").filter(Boolean).pop() || "";
931
+ if (!lastSegment.includes(".")) {
915
932
  return {
916
933
  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.`
934
+ 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
935
  };
919
936
  }
920
937
  return { valid: true };
921
938
  }
922
939
  /**
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.
940
+ * Find an existing lock that conflicts with the requested path (exact match).
941
+ * Paths are normalized before comparison so absolute and relative paths
942
+ * targeting the same file are correctly detected as conflicts.
944
943
  */
945
- findHierarchicalConflict(requestedPath, requestingAgent, locks) {
944
+ findExactConflict(requestedPath, requestingAgent, locks) {
946
945
  const normalizedRequested = _NerveCenter.normalizeLockPath(requestedPath);
947
946
  for (const lock of locks) {
948
947
  if (lock.agentId === requestingAgent) continue;
949
948
  const isStale = Date.now() - lock.timestamp > this.lockTimeout;
950
949
  if (isStale) continue;
951
950
  const normalizedLock = _NerveCenter.normalizeLockPath(lock.filePath);
952
- if (_NerveCenter.pathsConflict(normalizedRequested, normalizedLock)) {
951
+ if (normalizedRequested === normalizedLock) {
953
952
  return lock;
954
953
  }
955
954
  }
@@ -960,38 +959,42 @@ ${notepad}`;
960
959
  logger.info(`[proposeFileAccess] Starting - agentId: ${agentId}, filePath: ${filePath}`);
961
960
  const normalizedPath = _NerveCenter.normalizeLockPath(filePath);
962
961
  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}`);
962
+ const fileCheck = await _NerveCenter.validateFileOnly(filePath);
963
+ if (!fileCheck.valid) {
964
+ logger.warn(`[proposeFileAccess] REJECTED \u2014 not a file: ${fileCheck.reason}`);
966
965
  return {
967
966
  status: "REJECTED",
968
- message: scopeCheck.reason
967
+ message: fileCheck.reason
969
968
  };
970
969
  }
971
970
  if (this.contextManager.apiUrl) {
972
971
  try {
973
972
  const result = await this.callCoordination("locks", "POST", {
974
973
  action: "lock",
975
- filePath,
974
+ filePath: normalizedPath,
976
975
  agentId,
977
976
  intent,
978
977
  userPrompt
979
978
  });
980
979
  if (result.status === "DENIED") {
981
980
  logger.info(`[proposeFileAccess] DENIED by server: ${result.message}`);
982
- this.logLockEvent("BLOCKED", filePath, agentId, result.current_lock?.agent_id, intent);
981
+ await this.logLockEvent("BLOCKED", normalizedPath, agentId, result.current_lock?.agent_id, intent);
983
982
  return {
984
983
  status: "REQUIRES_ORCHESTRATION",
985
- message: result.message || `File '${filePath}' is locked by another agent`,
984
+ message: result.message || `File '${normalizedPath}' is locked by another agent`,
986
985
  currentLock: result.current_lock
987
986
  };
988
987
  }
988
+ if (result.status === "REJECTED") {
989
+ logger.warn(`[proposeFileAccess] REJECTED by server: ${result.message}`);
990
+ return { status: "REJECTED", message: result.message };
991
+ }
989
992
  logger.info(`[proposeFileAccess] GRANTED by server`);
990
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
993
+ await this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
991
994
  await this.appendToNotepad(`
992
- - [LOCK] ${agentId} locked ${filePath}
995
+ - [LOCK] ${agentId} locked ${normalizedPath}
993
996
  Intent: ${intent}`);
994
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
997
+ return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
995
998
  } catch (e) {
996
999
  if (e.message && e.message.includes("409")) {
997
1000
  logger.info(`[proposeFileAccess] Lock conflict (409)`);
@@ -1004,10 +1007,10 @@ ${notepad}`;
1004
1007
  }
1005
1008
  } catch {
1006
1009
  }
1007
- this.logLockEvent("BLOCKED", filePath, agentId, blockingAgent, intent);
1010
+ await this.logLockEvent("BLOCKED", normalizedPath, agentId, blockingAgent, intent);
1008
1011
  return {
1009
1012
  status: "REQUIRES_ORCHESTRATION",
1010
- message: `File '${filePath}' is locked by another agent`
1013
+ message: `File '${normalizedPath}' is locked by another agent`
1011
1014
  };
1012
1015
  }
1013
1016
  logger.error(`[proposeFileAccess] API lock failed: ${e.message}`, e);
@@ -1016,29 +1019,9 @@ ${notepad}`;
1016
1019
  }
1017
1020
  if (this.useSupabase && this.supabase && this._projectId) {
1018
1021
  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
1022
  const { data, error } = await this.supabase.rpc("try_acquire_lock", {
1040
1023
  p_project_id: this._projectId,
1041
- p_file_path: filePath,
1024
+ p_file_path: normalizedPath,
1042
1025
  p_agent_id: agentId,
1043
1026
  p_intent: intent,
1044
1027
  p_user_prompt: userPrompt,
@@ -1047,45 +1030,44 @@ ${notepad}`;
1047
1030
  if (error) throw error;
1048
1031
  const row = Array.isArray(data) ? data[0] : data;
1049
1032
  if (row && row.status === "DENIED") {
1050
- this.logLockEvent("BLOCKED", filePath, agentId, row.owner_id, intent);
1033
+ await this.logLockEvent("BLOCKED", normalizedPath, agentId, row.owner_id, intent);
1051
1034
  return {
1052
1035
  status: "REQUIRES_ORCHESTRATION",
1053
- message: `Conflict: File '${filePath}' is locked by '${row.owner_id}'`,
1036
+ message: `Conflict: File '${normalizedPath}' is locked by '${row.owner_id}'`,
1054
1037
  currentLock: {
1055
1038
  agentId: row.owner_id,
1056
- filePath,
1039
+ filePath: normalizedPath,
1057
1040
  intent: row.intent,
1058
1041
  timestamp: row.updated_at ? Date.parse(row.updated_at) : Date.now()
1059
1042
  }
1060
1043
  };
1061
1044
  }
1062
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
1045
+ await this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
1063
1046
  await this.appendToNotepad(`
1064
- - [LOCK] ${agentId} locked ${filePath}
1047
+ - [LOCK] ${agentId} locked ${normalizedPath}
1065
1048
  Intent: ${intent}`);
1066
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
1049
+ return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
1067
1050
  } catch (e) {
1068
1051
  logger.warn("[NerveCenter] Lock RPC failed. Falling back to local.", e);
1069
1052
  }
1070
1053
  }
1071
1054
  const allLocks = Object.values(this.state.locks);
1072
- const conflict = this.findHierarchicalConflict(filePath, agentId, allLocks);
1055
+ const conflict = this.findExactConflict(filePath, agentId, allLocks);
1073
1056
  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);
1057
+ await this.logLockEvent("BLOCKED", normalizedPath, agentId, conflict.agentId, intent);
1076
1058
  return {
1077
1059
  status: "REQUIRES_ORCHESTRATION",
1078
- message: `Conflict: '${filePath}' overlaps with '${conflict.filePath}' locked by '${conflict.agentId}'`,
1060
+ message: `Conflict: File '${normalizedPath}' is locked by '${conflict.agentId}'`,
1079
1061
  currentLock: conflict
1080
1062
  };
1081
1063
  }
1082
- this.state.locks[filePath] = { agentId, filePath, intent, userPrompt, timestamp: Date.now() };
1064
+ this.state.locks[normalizedPath] = { agentId, filePath: normalizedPath, intent, userPrompt, timestamp: Date.now() };
1083
1065
  await this.saveState();
1084
- this.logLockEvent("GRANTED", filePath, agentId, void 0, intent);
1066
+ await this.logLockEvent("GRANTED", normalizedPath, agentId, void 0, intent);
1085
1067
  await this.appendToNotepad(`
1086
- - [LOCK] ${agentId} locked ${filePath}
1068
+ - [LOCK] ${agentId} locked ${normalizedPath}
1087
1069
  Intent: ${intent}`);
1088
- return { status: "GRANTED", message: `Access granted for ${filePath}` };
1070
+ return { status: "GRANTED", message: `Access granted for ${normalizedPath}` };
1089
1071
  });
1090
1072
  }
1091
1073
  async updateSharedContext(text, agentId) {
@@ -2069,7 +2051,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2069
2051
  // --- Decision & Orchestration ---
2070
2052
  {
2071
2053
  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.",
2054
+ 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
2055
  inputSchema: {
2074
2056
  type: "object",
2075
2057
  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.1",
4
4
  "description": "Axis MCP Server CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {