@virsanghavi/axis-server 1.7.2 → 1.8.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 +64 -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,6 +876,38 @@ ${notepad}`;
876
876
  }
877
877
  }
878
878
  // --- Decision & Orchestration ---
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)
887
+ */
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;
895
+ }
896
+ /**
897
+ * Find any existing lock that conflicts hierarchically with the requested path.
898
+ * Skips locks owned by the same agent and stale locks.
899
+ */
900
+ findHierarchicalConflict(requestedPath, requestingAgent, locks) {
901
+ for (const lock of locks) {
902
+ if (lock.agentId === requestingAgent) continue;
903
+ const isStale = Date.now() - lock.timestamp > this.lockTimeout;
904
+ if (isStale) continue;
905
+ if (_NerveCenter.pathsConflict(requestedPath, lock.filePath)) {
906
+ return lock;
907
+ }
908
+ }
909
+ return null;
910
+ }
879
911
  async proposeFileAccess(agentId, filePath, intent, userPrompt) {
880
912
  return await this.mutex.runExclusive(async () => {
881
913
  logger.info(`[proposeFileAccess] Starting - agentId: ${agentId}, filePath: ${filePath}`);
@@ -927,6 +959,26 @@ ${notepad}`;
927
959
  }
928
960
  if (this.useSupabase && this.supabase && this._projectId) {
929
961
  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
+ }
930
982
  const { data, error } = await this.supabase.rpc("try_acquire_lock", {
931
983
  p_project_id: this._projectId,
932
984
  p_file_path: filePath,
@@ -959,17 +1011,16 @@ ${notepad}`;
959
1011
  logger.warn("[NerveCenter] Lock RPC failed. Falling back to local.", e);
960
1012
  }
961
1013
  }
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
- }
1014
+ const allLocks = Object.values(this.state.locks);
1015
+ const conflict = this.findHierarchicalConflict(filePath, agentId, allLocks);
1016
+ 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);
1019
+ return {
1020
+ status: "REQUIRES_ORCHESTRATION",
1021
+ message: `Conflict: '${filePath}' overlaps with '${conflict.filePath}' locked by '${conflict.agentId}'`,
1022
+ currentLock: conflict
1023
+ };
973
1024
  }
974
1025
  this.state.locks[filePath] = { agentId, filePath, intent, userPrompt, timestamp: Date.now() };
975
1026
  await this.saveState();
@@ -1961,7 +2012,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1961
2012
  // --- Decision & Orchestration ---
1962
2013
  {
1963
2014
  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.",
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.",
1965
2016
  inputSchema: {
1966
2017
  type: "object",
1967
2018
  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.0",
4
4
  "description": "Axis MCP Server CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {